fix: align custom tool SSE lifecycle with OpenAI API compatibility - #158
fix: align custom tool SSE lifecycle with OpenAI API compatibility#158maralbahari wants to merge 21 commits into
Conversation
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
franciscojavierarceo
left a comment
There was a problem hiding this comment.
left a few inline comments.
ashwing
left a comment
There was a problem hiding this comment.
Focused on the accumulator/normalizer seam since the client-contract and public-API surface is already well covered. Two things there — a coverage gap on the done-only path and the input-extraction heuristic.
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
|
@franciscojavierarceo @ashwing
|
| pending_bytes: usize, | ||
| } | ||
|
|
||
| impl FunctionSseTranslator { |
There was a problem hiding this comment.
Function-call SSE translation refactor
- Detecting normalized function calls that represent gateway-owned tools.
- Hiding their internal
function_callevents. - Buffering calls whose names were unavailable in
output_item.added. - Flushing ordinary function events once their ownership was known.
- Deferring events after a gateway-owned call to preserve output ordering.
- Synthesizing the public custom-tool lifecycle after inference completed.
This logic was spread across emit_upstream_stream_event, defer_after_gateway_call, record_first_hidden_gateway_output_index, defer_or_flush_function_event, should_hide_upstream_event, flush_pending_function_events, and drop_pending_function_events in executor/upstream.rs.
The refactor moves function-call classification and public wire translation into a dedicated FunctionSseTranslator. The translator receives only a request-scoped name → ToolType map, rather than the full ToolRegistry, and handles normalized function calls according to their declared public type:
- Ordinary functions and namespace members pass through unchanged.
- Custom tools are translated incrementally from
function_call_arguments.*intocustom_tool_call_input.*. - Gateway-owned MCP and web-search function frames are suppressed until their execution-specific public lifecycle is emitted.
- Calls without an initial name are buffered only until their type can be resolved, with a 256 KiB safety limit.
ResponseAccumulator still processes the original upstream frame first. This keeps the normalized FunctionToolCall as the authoritative internal representation used for dispatch and model continuation. The translator then reads that accumulated call state to produce client-facing frames, avoiding a second argument accumulator or changes to internal output items.
| return Ok(FunctionSseTranslation::default()); | ||
| } | ||
|
|
||
| let pending = self.take_pending(item_id); |
There was a problem hiding this comment.
pending_unnamed is keyed only by the original item_id, so when output_item.done supplies a new stable ID we lose the buffered output_item.added and argument deltas. i reproduced this and only output_item.done reached the client. we should resolve pending calls by item_id or output_index and add this case as a regression test.
There was a problem hiding this comment.
Pending calls are now resolved by either the original item_id or output_index, with a regression test confirming buffered added and delta frames survive when output_item.done supplies a new stable ID.
| item_type: SSEItemType::FunctionCall, | ||
| name: None, | ||
| .. | ||
| } => self.buffer_unnamed(item_id.clone(), frame), |
There was a problem hiding this comment.
an unnamed function call doesn’t establish a defer boundary while we wait to classify it. this lets later output items reach the client before an earlier custom or gateway-owned call, leaving the SSE lifecycle out of order. we should establish a provisional boundary at the unnamed call’s output_index and release it once the call is classified.
There was a problem hiding this comment.
Unnamed calls now establish a provisional defer boundary at their output_index; it is released and queued frames are flushed after client-owned classification, or retained when the call is gateway-owned.
|
|
||
| fragments.push("Provide the raw tool input in the `input` string field.".to_owned()); | ||
|
|
||
| if let Some(format) = ¶m.format { |
There was a problem hiding this comment.
the Lark or regex grammar is only copied into the function description while the generated input schema still accepts any string. that doesn’t preserve the constrained-decoding behavior of an OpenAI custom tool grammar. we should enforce or validate the grammar, or reject grammar formats we can’t support instead of treating them as prompt guidance. OpenAI’s grammar documentation
There was a problem hiding this comment.
Custom-tool grammar formats are no longer copied into the function description as prompt guidance. CustomHandler::validate now rejects any format declaration because function normalization cannot preserve OpenAI’s constrained-decoding semantics. RequestPayload::to_upstream_request() invokes ResponsesTool::validate() before normalization, and regression tests cover both Lark and regex formats. Unformatted custom tools remain supported.
| /// Converts client-facing custom-tool selectors to the function-tool shape | ||
| /// used by the normalized upstream tool declarations. | ||
| #[must_use] | ||
| pub(crate) fn normalized_for_upstream(&self) -> Self { |
There was a problem hiding this comment.
normalized_for_upstream rewrites every custom selector as a function selector without checking the corresponding declaration. a request with a function named echo and tool_choice: {"type":"custom","name":"echo"} is therefore accepted as a function choice instead of rejected. we should validate selectors against the declaration map before normalizing them.
There was a problem hiding this comment.
Custom selectors are now validated against the request’s custom-tool declaration map before normalization, so a custom selector cannot silently target a function with the same name; valid custom selectors and allowed_tools entries are rewritten to functions only after validation.
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Summary
This PR fixes custom tool support across the gateway while preserving OpenAI’s public Responses API contract.
What was wrong
vLLM accepts a
type: "custom"declaration, but exposes the resulting call as a regularfunction_call. Its streaming response emits:response.function_call_arguments.deltaresponse.function_call_arguments.doneIt does not emit OpenAI-compatible custom-tool events:
response.custom_tool_call_input.deltaresponse.custom_tool_call_input.doneForwarding vLLM’s response directly therefore leaked the internal normalized function shape instead of returning a public
custom_tool_call.What changed
The gateway now:
inputparameter.custom_tool_call.response.output_item.addedresponse.custom_tool_call_input.deltaresponse.custom_tool_call_input.doneresponse.output_item.donectc_item ID throughout the lifecycle.Testing
Added OpenAI and gateway cassettes for streaming and non-streaming two-turn custom-tool flows. Tests verify:
function_callitems do not leak.custom_tool_call_outputworks correctly on the continuation turn.